You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

628 lines
22 KiB

<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
import { renderSafeMarkdown } from '../../utils/render-markdown'
import {
formatOccurredOnDisplay,
formatPublishedDateOnly,
occurredOnToIsoAttr,
} from '../../utils/timeline-datetime'
definePageMeta({
layout: 'public',
})
const route = useRoute()
const slug = computed(() => route.params.publicSlug as string)
const { mode } = usePublicProfileLayoutMode()
type ReadingSection = 'posts' | 'timeline' | 'rss'
const readingSection = ref<ReadingSection>('posts')
type PublicPostListItem = {
title: string
excerpt: string
slug: string
publishedAt: Date | null
coverUrl?: string | null
}
type PublicTimelineItem = {
id?: number
title?: string | null
occurredOn?: Date | string | null
linkUrl?: string | null
bodyMarkdown?: string | null
}
type PublicRssListItem = { title?: string | null; canonicalUrl?: string | null; canonical_url?: string | null }
type Payload = {
user: { publicSlug: string | null; nickname: string | null; avatar: string | null }
bio: { markdown: string } | null
links: { label: string; url: string; visibility: string }[]
posts: { items: PublicPostListItem[]; total: number }
timeline: { items: PublicTimelineItem[]; total: number }
rssItems: { items: PublicRssListItem[]; total: number }
}
function rssPublicHref(it: PublicRssListItem): string | undefined {
const u = it.canonicalUrl ?? it.canonical_url
return typeof u === 'string' && u.trim().length ? u : undefined
}
function rssPublicTitle(it: PublicRssListItem): string {
const t = it.title
return typeof t === 'string' && t.trim().length ? t : '未命名'
}
function rssHostname(href: string | undefined): string {
if (!href) {
return ''
}
try {
return new URL(href).hostname
}
catch {
return href
}
}
const { data, pending, error } = await useAsyncData(
() => `public-profile-${slug.value}`,
async () => {
const res = await $fetch<ApiResponse<Payload>>(`/api/public/profile/${encodeURIComponent(slug.value)}`)
return unwrapApiBody(res)
},
{ watch: [slug] },
)
function firstReadingSection(d: Payload): ReadingSection {
if (d.posts.total > 0) {
return 'posts'
}
if (d.timeline.total > 0) {
return 'timeline'
}
return 'rss'
}
function readingSectionValid(d: Payload, s: ReadingSection): boolean {
if (s === 'posts') {
return d.posts.total > 0
}
if (s === 'timeline') {
return d.timeline.total > 0
}
return d.rssItems.total > 0
}
watch([data, mode], () => {
if (!data.value || mode.value !== 'detailed') {
return
}
const d = data.value
if (!d.posts.total && !d.timeline.total && !d.rssItems.total) {
return
}
if (!readingSectionValid(d, readingSection.value)) {
readingSection.value = firstReadingSection(d)
}
}, { immediate: true })
function selectReadingSection(s: ReadingSection) {
if (!data.value || !readingSectionValid(data.value, s)) {
return
}
readingSection.value = s
nextTick(() => {
document.getElementById('public-reading-main')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}
function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
return e.id ?? i
}
const bioHtml = computed(() =>
data.value?.bio?.markdown ? renderSafeMarkdown(data.value.bio.markdown) : '',
)
</script>
<template>
<div v-if="pending" class="text-muted py-10">
<UContainer>加载中…</UContainer>
</div>
<UContainer v-else-if="error" class="py-10">
<UAlert color="error" title="无法加载主页" />
</UContainer>
<!-- 展示:居中窄栏,原版卡片 + 时间轴 -->
<UContainer
v-else-if="data && mode === 'showcase'"
class="py-10 space-y-10 max-w-2xl"
>
<div class="flex flex-col gap-2">
<div v-if="data.user.avatar" class="flex justify-center">
<img
:src="data.user.avatar"
alt=""
class="h-20 w-20 rounded-full object-cover border border-default"
>
</div>
<h1 class="text-2xl font-semibold text-center">
{{ data.user.nickname || data.user.publicSlug || slug }}
</h1>
</div>
<div v-if="data.bio?.markdown" class="space-y-3">
<div
class="prose prose-neutral dark:prose-invert max-w-none prose-img:rounded-lg"
v-html="bioHtml"
/>
<div class="not-prose">
<NuxtLink
:to="`/@${slug}/about`"
class="text-sm font-medium text-primary hover:underline"
>
查看全文
</NuxtLink>
</div>
</div>
<div v-if="data.links.length" class="space-y-2">
<h2 class="text-lg font-medium">
链接
</h2>
<ul class="space-y-1">
<li v-for="(l, i) in data.links" :key="i">
<a
:href="l.url"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>{{ l.label }}</a>
</li>
</ul>
</div>
<div v-if="data.posts.total" class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-medium">
文章
</h2>
<UButton
v-if="data.posts.total > 5"
:to="`/@${slug}/posts`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部(共 {{ data.posts.total }} 条)
</UButton>
</div>
<ul class="space-y-2">
<li
v-for="p in data.posts.items"
:key="p.slug"
class="border border-default rounded-lg overflow-hidden transition-colors hover:bg-elevated/50"
>
<NuxtLink
:to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`"
class="block p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
>
<time
v-if="p.publishedAt"
class="block text-xs font-medium tabular-nums text-muted"
:datetime="occurredOnToIsoAttr(p.publishedAt)"
>{{ formatPublishedDateOnly(p.publishedAt) }}</time>
<div class="mt-1 font-medium text-highlighted">
{{ p.title }}
</div>
<div v-if="p.excerpt" class="text-sm text-muted mt-1">
{{ p.excerpt }}
</div>
</NuxtLink>
</li>
</ul>
</div>
<div v-if="data.timeline.total" class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-medium text-highlighted">
时光机
</h2>
<UButton
v-if="data.timeline.total > 5"
:to="`/@${slug}/timeline`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部(共 {{ data.timeline.total }} 条)
</UButton>
</div>
<ul class="relative space-y-0">
<li
v-for="(e, i) in data.timeline.items"
:key="timelineItemKey(e, i)"
class="relative flex gap-4 pb-6 pl-1 last:pb-0"
>
<div
v-if="i < data.timeline.items.length - 1"
class="absolute left-[11px] top-5 bottom-0 w-px bg-default"
aria-hidden="true"
/>
<div class="relative z-[1] flex shrink-0 flex-col items-center pt-0.5">
<span class="size-2.5 rounded-full bg-primary ring-4 ring-primary/15" />
</div>
<article
class="min-w-0 flex-1 rounded-lg border border-default bg-elevated/40 px-3 py-3 shadow-sm"
>
<time
class="block text-xs font-medium tabular-nums text-muted"
:datetime="e.occurredOn ? occurredOnToIsoAttr(e.occurredOn) : undefined"
>{{ formatOccurredOnDisplay(e.occurredOn ?? '') }}</time>
<div class="mt-1.5 font-medium text-highlighted">
{{ e.title }}
</div>
<a
v-if="e.linkUrl"
:href="e.linkUrl"
target="_blank"
rel="noopener noreferrer"
class="mt-2 inline-block text-sm text-primary hover:underline"
>相关链接</a>
</article>
</li>
</ul>
</div>
<div v-if="data.rssItems.total" class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-medium">
阅读
</h2>
<UButton
v-if="data.rssItems.total > 5"
:to="`/@${slug}/reading`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部(共 {{ data.rssItems.total }} 条)
</UButton>
</div>
<ul class="space-y-2">
<li
v-for="(it, i) in data.rssItems.items"
:key="i"
class="rounded-lg border border-default transition-colors hover:bg-elevated/40"
>
<a
v-if="rssPublicHref(it)"
:href="rssPublicHref(it)"
target="_blank"
rel="noopener noreferrer"
class="block p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
>
<div class="font-medium text-primary">
{{ rssPublicTitle(it) }}
</div>
<div class="mt-1 text-sm text-muted break-all">
{{ rssHostname(rssPublicHref(it)) }}
</div>
</a>
<div v-else class="p-3 text-muted">
{{ rssPublicTitle(it) }}
</div>
</li>
</ul>
</div>
<UEmpty
v-if="!data.posts.total && !data.timeline.total && !data.rssItems.total && !data.bio && !data.links.length"
title="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。"
/>
</UContainer>
<!-- 阅读:侧栏 + 与展示一致的预览块 -->
<UContainer
v-else-if="data && mode === 'detailed'"
class="py-8 lg:py-10 max-w-6xl"
>
<div class="lg:grid lg:grid-cols-[12.5rem_minmax(0,1fr)] lg:gap-10 xl:grid-cols-[14rem_minmax(0,1fr)] xl:gap-12">
<aside class="mb-10 lg:mb-0">
<div class="lg:sticky lg:top-20 space-y-6">
<div class="flex flex-col gap-3 border-b border-default pb-6 lg:border-0 lg:pb-0">
<div v-if="data.user.avatar" class="flex justify-center lg:justify-start">
<img
:src="data.user.avatar"
alt=""
class="h-16 w-16 rounded-full object-cover border border-default lg:h-14 lg:w-14"
>
</div>
<h1 class="text-center text-lg font-semibold text-highlighted lg:text-left lg:text-base">
{{ data.user.nickname || data.user.publicSlug || slug }}
</h1>
<div v-if="data.bio?.markdown" class="space-y-2">
<div
class="bio-preview-scroll max-h-36 overflow-y-auto rounded-lg border border-default bg-elevated/30 p-3 text-xs leading-relaxed text-muted prose prose-neutral dark:prose-invert max-w-none prose-p:my-1 prose-img:rounded"
v-html="bioHtml"
/>
<NuxtLink
:to="`/@${slug}/about`"
class="text-xs font-medium text-primary hover:underline"
>
查看全文
</NuxtLink>
</div>
<div v-if="data.links.length" class="space-y-1.5">
<div class="text-xs font-medium uppercase tracking-wide text-muted">
链接
</div>
<ul class="space-y-1 text-sm">
<li v-for="(l, i) in data.links" :key="i">
<a
:href="l.url"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline break-all"
>{{ l.label }}</a>
</li>
</ul>
</div>
</div>
<nav
class="flex flex-wrap gap-2 border-t border-default pt-4 lg:hidden"
aria-label="页面区块"
>
<button
v-if="data.posts.total"
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="readingSection === 'posts' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'"
@click="selectReadingSection('posts')"
>
文章 · {{ data.posts.total }}
</button>
<button
v-if="data.timeline.total"
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="readingSection === 'timeline' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'"
@click="selectReadingSection('timeline')"
>
时光机 · {{ data.timeline.total }}
</button>
<button
v-if="data.rssItems.total"
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="readingSection === 'rss' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'"
@click="selectReadingSection('rss')"
>
阅读 · {{ data.rssItems.total }}
</button>
</nav>
<nav class="hidden lg:block space-y-1 border-t border-default pt-4 text-sm" aria-label="页面区块">
<button
v-if="data.posts.total"
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors"
:class="readingSection === 'posts' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'"
@click="selectReadingSection('posts')"
>
<span>文章</span>
<span class="tabular-nums text-xs opacity-80">{{ data.posts.total }}</span>
</button>
<button
v-if="data.timeline.total"
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors"
:class="readingSection === 'timeline' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'"
@click="selectReadingSection('timeline')"
>
<span>时光机</span>
<span class="tabular-nums text-xs opacity-80">{{ data.timeline.total }}</span>
</button>
<button
v-if="data.rssItems.total"
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors"
:class="readingSection === 'rss' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'"
@click="selectReadingSection('rss')"
>
<span>阅读</span>
<span class="tabular-nums text-xs opacity-80">{{ data.rssItems.total }}</span>
</button>
</nav>
</div>
</aside>
<div id="public-reading-main" class="min-w-0 scroll-mt-24 space-y-14">
<section
v-show="readingSection === 'posts' && data.posts.total"
id="public-reading-posts"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
文章
</h2>
<UButton
v-if="data.posts.total > 5"
:to="`/@${slug}/posts`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部(共 {{ data.posts.total }} 条)
</UButton>
</div>
<ul class="border-t border-default">
<li
v-for="p in data.posts.items"
:key="p.slug"
class="border-b border-default last:border-b-0"
>
<NuxtLink
:to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`"
class="group flex flex-col gap-4 py-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-default rounded-lg sm:flex-row sm:gap-5"
>
<div
v-if="p.coverUrl"
class="w-full shrink-0 overflow-hidden rounded-xl border border-default sm:w-40"
>
<img
:src="p.coverUrl"
alt=""
class="aspect-[16/9] w-full object-cover sm:aspect-[4/3] sm:min-h-[7rem] sm:h-full"
>
</div>
<div class="min-w-0 flex-1">
<time
v-if="p.publishedAt"
class="block text-xs font-medium tabular-nums text-muted"
:datetime="occurredOnToIsoAttr(p.publishedAt)"
>{{ formatPublishedDateOnly(p.publishedAt) }}</time>
<div class="mt-1 text-xl font-semibold text-pretty text-highlighted leading-snug group-hover:text-primary transition-colors">
{{ p.title }}
</div>
<p
v-if="p.excerpt"
class="mt-3 text-sm text-muted text-pretty leading-relaxed"
>
{{ p.excerpt }}
</p>
</div>
</NuxtLink>
</li>
</ul>
</section>
<section
v-show="readingSection === 'timeline' && data.timeline.total"
id="public-reading-timeline"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
时光机
</h2>
<UButton
v-if="data.timeline.total > 5"
:to="`/@${slug}/timeline`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部(共 {{ data.timeline.total }} 条)
</UButton>
</div>
<ul class="relative space-y-0">
<li
v-for="(e, i) in data.timeline.items"
:key="timelineItemKey(e, i)"
class="relative flex gap-4 pb-6 pl-1 last:pb-0"
>
<div
v-if="i < data.timeline.items.length - 1"
class="absolute left-[11px] top-5 bottom-0 w-px bg-default"
aria-hidden="true"
/>
<div class="relative z-[1] flex shrink-0 flex-col items-center pt-0.5">
<span class="size-2.5 rounded-full bg-primary ring-4 ring-primary/15" />
</div>
<article
class="min-w-0 flex-1 rounded-xl border border-default bg-elevated/25 px-5 py-5 shadow-sm sm:px-6"
>
<div class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<time
class="shrink-0 text-xs font-medium tabular-nums text-muted sm:order-2 sm:text-right"
:datetime="e.occurredOn ? occurredOnToIsoAttr(e.occurredOn) : undefined"
>{{ formatOccurredOnDisplay(e.occurredOn ?? '') }}</time>
<h3 class="text-pretty text-lg font-semibold text-highlighted sm:order-1 sm:min-w-0 sm:flex-1">
{{ e.title }}
</h3>
</div>
<p
v-if="e.bodyMarkdown && e.bodyMarkdown.trim()"
class="mt-4 whitespace-pre-wrap border-t border-default/60 pt-4 text-sm leading-relaxed text-default text-pretty"
>
{{ e.bodyMarkdown }}
</p>
<a
v-if="e.linkUrl"
:href="e.linkUrl"
target="_blank"
rel="noopener noreferrer"
class="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
<span>打开链接</span>
<UIcon name="i-lucide-external-link" class="size-3.5 opacity-80" />
</a>
</article>
</li>
</ul>
</section>
<section
v-show="readingSection === 'rss' && data.rssItems.total"
id="public-reading-rss"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
阅读
</h2>
<UButton
v-if="data.rssItems.total > 5"
:to="`/@${slug}/reading`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部(共 {{ data.rssItems.total }} 条)
</UButton>
</div>
<ul class="border-t border-default">
<li
v-for="(it, i) in data.rssItems.items"
:key="i"
class="border-b border-default last:border-b-0"
>
<a
v-if="rssPublicHref(it)"
:href="rssPublicHref(it)"
target="_blank"
rel="noopener noreferrer"
class="group block py-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
>
<div class="text-base font-semibold text-pretty text-primary group-hover:underline">
{{ rssPublicTitle(it) }}
</div>
<div class="mt-1.5 break-all text-sm text-muted">
{{ rssHostname(rssPublicHref(it)) }}
</div>
</a>
<div v-else class="py-5 text-muted">
{{ rssPublicTitle(it) }}
</div>
</li>
</ul>
</section>
<UEmpty
v-if="!data.posts.total && !data.timeline.total && !data.rssItems.total && !data.bio && !data.links.length"
title="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。"
/>
</div>
</div>
</UContainer>
</template>